Lambda関数でメモリサイズよりも大きいファイルを圧縮してみた
メモリサイズよりも大きいファイルを圧縮したい
こんにちは、のんピ(@non____97)です。
皆さんはLambda関数でメモリサイズよりも大きいファイルを圧縮したいなと思ったことはありますか? 私はあります。
Lambda関数を用いてAurora PostgreSQLのログファイルをS3バケットへPUTする方法を紹介しました。
Lambda関数上では圧縮を行なっています。
こちらの記事にて紹介しているとおり、メモリサイズ以上のログファイルを圧縮しようとした場合、以下MemoryError
が出力されます。
{
"level": "ERROR",
"location": "_compress_file:82",
"message": "Failed to compress file",
"timestamp": "2025-01-12 21:58:00,014+0000",
"service": "rds-log-file-uploader",
"cold_start": false,
"function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_memory_size": "128",
"function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_request_id": "0b382ab8-0756-45bf-9d9a-d9ed4acd3ed5",
"file_path": "/tmp/tmpelazbqeh",
"error": "",
"exception": "Traceback (most recent call last):\n File \"/var/task/rds_log_file_uploader.py\", line 66, in _compress_file\n f_out.write(f_in.read())\n ~~~~~~~~~^^\nMemoryError",
"exception_name": "MemoryError",
"stack_trace": {
"type": "MemoryError",
"value": "",
"module": "builtins",
"frames": [
{
"file": "/var/task/rds_log_file_uploader.py",
"line": 66,
"function": "_compress_file",
"statement": "f_out.write(f_in.read())"
}
]
},
"xray_trace_id": "1-67843ab6-31e0be55ff6eb2c44aa5ba3d"
}
ログファイルサイズはlog_rotation_size
で制御可能ですが、コスト観点からメモリサイズを小さくしたい場面もあるでしょう。
そのような場面にも対応できるようにLambda関数を修正してみました。
(メモリサイズを増やすことで処理時間が短くなり、結果としてトータルのコストが安くなることもあるという話は一旦置いておきます)
チャンクごとに分割して圧縮処理を行う
圧縮処理を行う際、従来では以下のようにファイル全体を読み込んだ上で圧縮していました。
# 圧縮
with open(file_path, "rb") as f_in:
with gzip.open(temp_path, "wb", compresslevel=6) as f_out:
f_out.write(f_in.read())
このタイミングで、以下のようにLambda関数でOutOfMemory
のエラーが出力されることがあります。
{
"time": "2025-01-12T22:44:38.770Z",
"type": "platform.runtimeDone",
"record": {
"requestId": "fc2dbe1e-a14d-4460-b66a-2828d020bb22",
"status": "error",
"errorType": "Runtime.OutOfMemory",
"tracing": {
"spanId": "be42d47a06a45c3a",
"type": "X-Amzn-Trace-Id",
"value": "Root=1-678445b8-3c6a69faee23b2abbd6e6452;Parent=23a71519c503dbee;Sampled=1;Lineage=1:b8e6c7fb:0"
},
"metrics": {
"durationMs": 20520.454,
"producedBytes": 0
}
}
}
{
"time": "2025-01-12T22:44:38.800Z",
"type": "platform.report",
"record": {
"requestId": "fc2dbe1e-a14d-4460-b66a-2828d020bb22",
"metrics": {
"durationMs": 20550.886,
"billedDurationMs": 20551,
"memorySizeMB": 128,
"maxMemoryUsedMB": 125,
"initDurationMs": 460.054
},
"tracing": {
"spanId": "be42d47a06a45c3a",
"type": "X-Amzn-Trace-Id",
"value": "Root=1-678445b8-3c6a69faee23b2abbd6e6452;Parent=23a71519c503dbee;Sampled=1;Lineage=1:b8e6c7fb:0"
},
"status": "error",
"errorType": "Runtime.OutOfMemory"
}
}
一度にファイル全体を読み込もうとするため、エラーになるのであれば、ある程度の区切り(チャンク)に分割して読み込むことで回避することが可能です。
チャンクサイズは大きければIO数が少なくなりますし、圧縮効率も良くなりそうです。一方で大きければ、その分メモリエラーになる可能性も高くなります。
そのため、今回はLambda関数のメモリサイズの1/8をチャンクサイズとして使用しました。
Lambda関数ではAWS_LAMBDA_FUNCTION_MEMORY_SIZE
という環境変数でメモリサイズを取得することが可能です。ランタイム環境変数は以下AWS公式ドキュメントをご覧ください。
具体的な処理は以下のようになります。
# Lambda関数のメモリサイズの1/8をチャンクサイズとして使用(バイト単位)
chunk_size = (
int(os.environ.get("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")) * 1024 * 1024
) // 8
logger.debug(
"Compressing file with chunks",
extra={
"file_path": file_path,
"original_size": original_size,
"chunk_size": chunk_size,
},
)
# チャンク単位で圧縮
with open(file_path, "rb") as f_in:
with gzip.open(temp_path, "wb", compresslevel=6) as f_out:
while True:
chunk = f_in.read(chunk_size)
if not chunk:
break
f_out.write(chunk)
メモリサイズを1/2や1/3、1/4などにした場合は以下のように[Errno 14] Bad address
となることが稀にあったので1/8としています。
{
"level": "ERROR",
"location": "_compress_file:103",
"message": "Failed to compress file",
"timestamp": "2025-01-13 00:30:17,807+0000",
"service": "rds-log-file-uploader",
"cold_start": true,
"function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_memory_size": "128",
"function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_request_id": "e5fa3142-1ae7-44f4-a283-13381936d43b",
"file_path": "/tmp/tmpgldv55xj",
"error": "[Errno 14] Bad address",
"exception": "Traceback (most recent call last):\n File \"/var/task/rds_log_file_uploader.py\", line 82, in _compress_file\n chunk = f_in.read(chunk_size)\nOSError: [Errno 14] Bad address",
"exception_name": "OSError",
"stack_trace": {
"type": "OSError",
"value": "[Errno 14] Bad address",
"module": "builtins",
"frames": [
{
"file": "/var/task/rds_log_file_uploader.py",
"line": 82,
"function": "_compress_file",
"statement": "chunk = f_in.read(chunk_size)"
}
]
},
"xray_trace_id": "1-67845e72-a91a86430064896d7487dda1"
}
1/8にしてから、合計100ファイルほどメモリサイズよりも大きいパターンを試しましたが、[Errno 14] Bad address
にはなっていません。
やってみた
既存オブジェクトの削除
実際に試してみます。
検証環境は前回記事と同じものを流用します。
まず、圧縮前で200MBのオブジェクトを削除します。
オブジェクトを削除する前に、チャンクごとに圧縮したことが原因で欠損が起きないか確認するためにAthenaでログレコードの件数を確認しておきます。
SELECT count(*)
FROM
postgresql_logs
WHERE
datehour>='2025/01/11/22'
# _col0
1 19094642
既存オブジェクトを削除します。
メモリサイズ128MBで最大200MBのファイルに対して圧縮
それでは、メモリサイズ128MBで最大200MBのファイルに対して圧縮を行います。
Lambda関数のメモリサイズを128MBに変更します。
この状態でステートマシンを実行します。実行時のパラメーターは以下のとおりです。
{
"DbClusterIdentifier": "database-1",
"LogDestinationBucket": "aurora-postgresql-log",
"LogRangeMinutes": 8800
}
1分50秒弱で完了しました。
オブジェクトのサイズを見ていると3.0MBと、削除前のオブジェクトと同じサイズでした。正常に圧縮されていそうですね。
Lambda関数が出力したログからも200MBのファイルを圧縮して3MBほどになっていることが確認できます。
{
"level": "INFO",
"location": "_compress_file:99",
"message": "Successfully compressed file",
"timestamp": "2025-01-14 01:47:20,859+0000",
"service": "rds-log-file-uploader",
"cold_start": false,
"function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_memory_size": "128",
"function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_request_id": "3bafe986-a7f6-4c05-a695-cd90e62638df",
"file_path": "/tmp/tmpzywq984o",
"original_size": 204800698,
"compressed_size": 3193423,
"compression_ratio": "1.56%",
"xray_trace_id": "1-6785c1d5-61b4ab7541564c9b079b59e9"
}
S3バケットにPUTされたことを確認したのでAthenaでクエリをかけてみます。
SELECT count(*)
FROM
postgresql_logs
WHERE
datehour>='2025/01/11/22'
# _col0
1 19094642
件数はオブジェクト削除前と全く同じですね。クエリキャッシュを利用するようにしていないので、確かに正常に圧縮できていることが分かります。
もちろん他のクエリも叩けられます。
次にX-Rayのトレース結果を見て、圧縮処理にどの程度時間が掛かっているのか確認してみます。
ログファイルを圧縮してS3バケットにPUTするLambda関数が軒並み保留中
となっています。数時間待っても結果は表示されませんでした。
その後、同じログファイルが対象となるように繰り返し実行しまいしたが、いずれも保留中
のままでした。メモリ負荷が高すぎてLambda関数からX-RayにPUTできていないのでしょうか。
メモリサイズ1,024MBで最大200MBのファイルに対して圧縮
続いてメモリサイズを1,024MBにして、同じログファイルを圧縮したときの実行時間を確認してみます。
Lambda関数のメモリサイズを変更します。
この状態でステートマシンを実行します。
1分ほど掛かってしまいました。
前回記事では30秒ほどで完了していました。
どこで時間がかかっているのか調査するためにX-Rayのトレース結果を確認してみましょう。
ログファイルをDBインスタンスからダウンロードするのに45秒ほどかかっているようですね。前回記事では12秒ほどで完了していました。S3バケットへのPUTするのにかかった時間は変わらず1秒未満であることから、Lambda関数のネットワーク帯域ではなく、Aurora Serverless v2側が調子悪いのではないかと想像します。
肝心の圧縮処理にかかった時間は3秒ほどでした。前回記事でも約3秒だったので圧縮速度に大きな違いはないようです。
ちなみに、もちろん全てのオブジェクトが圧縮されていました。
メモリサイズ1,024MBで最大1GBのファイルに対して圧縮
続いて、メモリサイズ1,024MBで最大1GBのファイルに対して圧縮をしてみます。
log_rotation_size
の最大は1GBです。つまりは1GB以上のログファイルは生成されません。
1GBのログファイルであってもLambda関数上で圧縮できることを確認します。
事前準備として、log_rotation_age
とlog_rotation_size
を最大に変更します。
その後、DBに大量に書き込んで1GBのログファイルを3つ出力されるまで待ちます。
ログファイルを抱え切れるように、Lambda関数でエフェメラルストレージとタイムアウト値を変更しておきます。
ステートマシンを実行します。
1分弱で完了しました。思ったより早いです。
S3バケットを確認すると、1GBのログファイルが15.2MBのオブジェクトとしてPUTされていました。
Lambda関数の圧縮時のログは以下の通りで、確かに1GBのログファイルを15MBほどに圧縮したことが分かります。
{
"level": "INFO",
"location": "_compress_file:99",
"message": "Successfully compressed file",
"timestamp": "2025-01-14 02:53:53,385+0000",
"service": "rds-log-file-uploader",
"cold_start": true,
"function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_memory_size": "1024",
"function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_request_id": "e4869872-1790-4805-9846-49319a513271",
"file_path": "/tmp/tmpevez8n2f",
"original_size": 1024005535,
"compressed_size": 15964528,
"compression_ratio": "1.56%",
"xray_trace_id": "1-6785d18c-4aca63643f9f5a3bc438eebf"
}
圧縮時間を確認するためにX-Rayのトレース結果を確認しましょう。
15秒ほどですね。体感速いような気がします。
メモリ使用量も確認しましょう。
CloudWatch Logs InsightsでLambda関数のメモリ使用量を確認します。
filter @type = "REPORT"
| stats
min(record.metrics.maxMemoryUsedMB) as minMemory,
max(record.metrics.maxMemoryUsedMB) as maxMemory,
avg(record.metrics.maxMemoryUsedMB) as avgMemory,
pct(record.metrics.maxMemoryUsedMB, 60) as p60Memory,
pct(record.metrics.maxMemoryUsedMB, 70) as p70Memory,
pct(record.metrics.maxMemoryUsedMB, 80) as p80Memory,
pct(record.metrics.maxMemoryUsedMB, 90) as p90Memory
minMemory | maxMemory | avgMemory | p60Memory | p70Memory | p80Memory | p90Memory |
---|---|---|---|---|---|---|
1010 | 1011 | 1010.6667 | 1011 | 1011 | 1011 | 1011 |
メモリを最大限使っていることが分かります。
メモリサイズ128MBで最大1GBのファイルに対して圧縮
最後にメモリサイズ128MBで最大1GBのファイルに対して圧縮をかけられるのか確認してみます。
Lambda関数のメモリサイズを128MBに変更してステートマシンを実行します。
すると、3分ほどで完了しました。
S3バケットを除くと前回実行時と同じく15.2MBに圧縮されていることが分かります。
Lambda関数の圧縮時のログは以下の通りで、確かにメモリサイズ128MBのLambda関数で1GBのログファイルを15MBほどに圧縮したことが分かります。
{
"level": "INFO",
"location": "_compress_file:99",
"message": "Successfully compressed file",
"timestamp": "2025-01-14 03:04:55,772+0000",
"service": "rds-log-file-uploader",
"cold_start": true,
"function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_memory_size": "128",
"function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
"function_request_id": "8a7acf45-3380-4337-be04-0a84a7d014e5",
"file_path": "/tmp/tmpf5yhjf_8",
"original_size": 1024005535,
"compressed_size": 15964528,
"compression_ratio": "1.56%",
"xray_trace_id": "1-6785d3a1-46ca6b392d73f29b3ed490ba"
}
Aurora PostgreSQLのログファイルの最大サイズである1GBをメモリサイズ128MBのLambda関数でも捌けられたので、どんなAurora PostgreSQLでも対応できそうですね。
X-Rayのトレース結果は以下のとおりです。
おおよそ圧縮するのに2分ほどかかっていますね。
Lambdaはメモリの割り当てサイズに応じて利用可能なCPUクロック周波数やNW帯域も比例して大きくなっていきます。
メモリを追加すると、CPU の処理量が比例的に増加して、計算能力全体が向上します。関数が CPU、ネットワーク、またはメモリにバインドされている場合、メモリ設定を増やすとパフォーマンスが大幅に向上する可能性があります。
そのため、処理に時間がかかるようになったのでしょう。
同様にDBインスタンスからログファイルをダウンロードするのにも55秒ほどかかるようになっています。(1,024MBの場合は30秒ほど)
Lambda関数のメモリ使用量も確認します。
filter @type = "REPORT"
| stats
min(record.metrics.maxMemoryUsedMB) as minMemory,
max(record.metrics.maxMemoryUsedMB) as maxMemory,
avg(record.metrics.maxMemoryUsedMB) as avgMemory,
pct(record.metrics.maxMemoryUsedMB, 60) as p60Memory,
pct(record.metrics.maxMemoryUsedMB, 70) as p70Memory,
pct(record.metrics.maxMemoryUsedMB, 80) as p80Memory,
pct(record.metrics.maxMemoryUsedMB, 90) as p90Memory
minMemory | maxMemory | avgMemory | p60Memory | p70Memory | p80Memory | p90Memory |
---|---|---|---|---|---|---|
125 | 125 | 125 | 125 | 125 | 125 | 125 |
はい、125MBと限界ギリギリまで使っていますね。
メモリに載せるデータ量を調整して処理しよう
Lambda関数でメモリサイズよりも大きいファイルを圧縮してみました。
AWS SDK for pandas (awswrangler)などで大きなデータを処理するときにでも使えそうですね。
とは言え、メモリギリギリを攻めるのは推奨されることではありません。
Lambda Insightsを導入するなどしてメモリ使用量をチェックし、メモリ消費が減るようなコードに変更する対応が必要でしょう。
今回使用したコードは以下リポジトリに保存しています。
この記事が誰かの助けになれば幸いです。
以上、クラウド事業本部 コンサルティング部の のんピ(@non____97)でした!